Masquerade bar#255
Conversation
|
Thanks for the pull request, @jesusbalderramawgu! This repository is currently maintained by Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review. 🔘 Get product approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. DetailsWhere can I find more information?If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources: When can I expect my changes to be merged?Our goal is to get community contributions seen and reviewed as efficiently as possible. However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
747d10b to
a07f661
Compare
|
|
||
| /* | ||
| * Collects route role strings from all apps that opted into the course | ||
| * navigation bar feature. Each app declares its roles as a string array: |
There was a problem hiding this comment.
we need to change this for masquerade bar feature 😅
| defaultMessage: 'Studio', | ||
| description: 'Button to view in studio', | ||
| }, | ||
| titleInsights: { |
There was a problem hiding this comment.
this one i think is not longer used
| @@ -0,0 +1,142 @@ | |||
| import React from 'react'; | |||
There was a problem hiding this comment.
i think this line is not needed
There was a problem hiding this comment.
it is in use
| import { defineMessages } from '@openedx/frontend-base'; | ||
|
|
||
| const messages = defineMessages({ | ||
| genericError: { |
There was a problem hiding this comment.
i think you can merge this messages, with the other file so we just have one absolute file for masquerade bar messages
arbrandes
left a comment
There was a problem hiding this comment.
I've made some bug-level comments inline, but the meat of the changes I'd like to see com from the patterns suggested below (with Claude's help, of course). They're informed by what the learner-dashboard masquerade bar does well and would also unlock the "kill the page reload" goal. They're listed in roughly the order I'd take them on.
Replace global.location.reload() with query invalidation
shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx:36-38, MasqueradeUserNameInput.tsx:21-22
The reload is the only thing keeping this bar's state model from matching the dashboard bar's. After the LMS accepts the masquerade POST, the cookie is updated server-side; everything else just needs to refetch. With the shell's QueryClientProvider in scope, that becomes:
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (payload: Payload) => postMasqueradeOptions(courseId, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['masquerade', courseId] });
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'course' }); // or similar
},
});Consumers (course outline, unit content, etc.) refetch under the new identity without losing scroll position, route, or any other in-page state. This is the single biggest engineering win and unblocks most of what follows.
Pull state and effects into a useMasqueradeWidget hook
shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx:31-103
Today the component body holds useQuery, useMutation, three useStates (autoFocus, shouldShowUserNameInput, activeOverride), three useEffects, two useCallbacks, and a useMemo that merges activeOverride onto queryActive. The Learner Dashboard bar's useMasqueradeBarData shows the alternative — a custom hook that owns all of that and returns flat data + handlers, leaving the component as JSX.
// hooks.ts
export function useMasqueradeWidget(courseId: string) {
const query = useQuery({ queryKey: ['masquerade', courseId], queryFn: () => getMasqueradeOptions(courseId) });
const mutation = useMutation({ /* ... */ });
const active = query.data?.success ? query.data.active : defaultActive;
const available = query.data?.success ? query.data.available : [];
const isError = query.isError || (query.data && !query.data.success) || mutation.isError;
const errorMessage = pickErrorMessage(query, mutation); // returns a MessageDescriptor
return { active, available, isError, errorMessage, isPending: mutation.isPending, submit: mutation.mutateAsync };
}Once this hook owns the state, MasqueradeWidget shrinks to ~30 lines of JSX wired to its return value, and MasqueradeBar no longer needs to lift masqueradeErrorMessage via callback (see below).
Derive state instead of mirroring it via useState + useEffect
shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx:65-70, shell/header/masquerade-bar/MasqueradeBar.tsx:40
Several pieces of state are just shadows of query/mutation status:
shouldShowUserNameInputis "did the server return auserName, or did the user click Specific Student" — both knowable without a seconduseState+useEffect.activeOverrideexists to optimistically reflect the dropdown selection until the page reloads. Once the reload is gone andinvalidateQueriesrefetches,query.data.activebecomes the source of truth and the override disappears.masqueradeErrorMessageinMasqueradeBaris just the mutation/query's error in a different bag, transferred via anonErrorcallback. With the hook above, the bar can read it directly fromuseMasqueradeWidget(or pass the widget into the bar) instead of routing it through state.
The rule of thumb: if a useEffect exists only to copy a value from a hook into a useState, the useState shouldn't exist. The same applies to setActiveOverride after a successful submit — let the query be the truth.
Use Paragon's StatefulButton and FormControlFeedback for submit UX
shell/header/masquerade-bar/masquerade-widget/MasqueradeUserNameInput.tsx:41-48, shell/header/masquerade-bar/MasqueradeBar.tsx:69-75
Today there is no pending state on the username submit and errors only render as a top-level Alert outside the form. The dashboard bar uses two Paragon primitives that fit better:
<FormGroup isInvalid={isMasqueradingFailed}>
<FormControl
value={input}
onChange={(e) => setInput(e.target.value)}
floatingLabel={formatMessage(messages.placeholder)}
/>
{isMasqueradingFailed && (
<FormControlFeedback type="invalid" hasIcon={false}>
{formatMessage(errorMessage)}
</FormControlFeedback>
)}
</FormGroup>
<StatefulButton
variant="brand"
state={isPending ? 'pending' : 'default'}
labels={{ default: formatMessage(messages.submit), pending: formatMessage(messages.submitting) }}
onClick={handleSubmit}
/>That gives users a spinner on the submit button, inline feedback under the field, and lets the top-level Alert go away (or be reserved for the GET-options failure case, which is the only error not tied to a specific input).
i18n'd error messages keyed by failure type
shell/header/masquerade-bar/masquerade-widget/MasqueradeWidget.tsx:42-53
Right now every error path produces the same English string, "Unable to get masquerade options". The dashboard bar shows a better pattern — derive the message from the error type and return a MessageDescriptor, not a pre-formatted string:
function pickErrorMessage(query, mutation) {
if (query.isError) return messages.failedToLoadOptions;
if (query.data && !query.data.success) return messages.serverRefusedOptions;
if (mutation.isError) {
const status = mutation.error?.customAttributes?.httpErrorStatus;
if (status === 404) return messages.noStudentFound;
return messages.genericSubmitError;
}
return null;
}The component then calls formatMessage(errorMessage) at the render site, keeping MessageDescriptors in the data layer and translation in the view layer. This also lets success: false from the server carry data.error straight through (postMasqueradeOptions already returns error?: string) for cases where the server's message is more specific than anything the frontend could pick.
Lift the username input value into the hook
shell/header/masquerade-bar/masquerade-widget/MasqueradeUserNameInput.tsx:34-48
The input is uncontrolled today: it seeds itself with defaultValue={active.userName ?? ''} and the submit handler reads event.currentTarget.value out of the keypress event. That means the value lives only in the DOM, the submit path is forced to be a key-event handler (which is why onKeyPress got reached for in the first place), and there's no way for the hook to validate, disable a submit button, or clear the field after success.
Once the hook owns the data, lift the value alongside it:
// in useMasqueradeWidget
const [userName, setUserName] = useState('');
// reset to whatever the server says is active when query data arrives
useEffect(() => { setUserName(active.userName ?? ''); }, [active.userName]);
const handleSubmit = () => mutation.mutateAsync({ role: 'student', user_name: userName });
return { /* ... */ userName, setUserName, handleSubmit };Then MasqueradeUserNameInput becomes a controlled FormControl driven by value / onChange, and submit fires off a button click (the StatefulButton from the previous section) rather than off Enter inside a key handler. Keyboard submit is still supported by wrapping the input + button in <form onSubmit={...}> — the difference is that userName is the source of truth and the event is just a trigger.
Collapse MasqueradeWidgetOption.handleClick to a single select(option) callback
shell/header/masquerade-bar/masquerade-widget/MasqueradeWidgetOption.tsx:23-40
Today each option's click handler decides for itself which path to take: if (userName || userName === '') opens the username input, otherwise it builds a Payload and calls onSubmit. The "is userName defined including the empty-string case" check is load-bearing (an empty string distinguishes "Specific Student..." from a fixed group like "Audit"), but it's exactly the kind of branch that belongs in the hook with the rest of the masquerade state, not duplicated per option.
Once the hook owns the decision, this component just forwards the option:
// in useMasqueradeWidget
const select = (option: MasqueradeOption) => {
if (option.userName !== undefined) {
setShowUserNameInput(true);
setPendingOption(option);
return;
}
return mutation.mutateAsync(toPayload(option));
};
// MasqueradeWidgetOption
const { select } = useMasqueradeContext();
return <Dropdown.Item onClick={() => select(option)}>{option.name}</Dropdown.Item>;That removes the if (userName || userName === '') check, the per-option Payload assembly, the role!/userPartitionId! non-null assertions, and the option-side global.location.reload() call. MasqueradeWidgetOption becomes a thin presentational wrapper, and the rules for what each option means live in one place.
Extract the Studio link as its own component
shell/header/masquerade-bar/MasqueradeBar.tsx:11-22, 56-66
getStudioUrl and the "View course in: Studio" JSX have nothing to do with masquerading — they're just an instructor-context affordance that happens to share the bar. Pulling them into a <StudioLink courseId unitId /> (or a slot of its own) lets the URL-building logic be tested in isolation, lets isStudioButtonVisible go away in favor of "render or don't", and shrinks MasqueradeBar down to the masquerade flow.
| export { default as shellApp } from './app'; | ||
| export { Footer, footerApp } from './footer'; | ||
| export { providesCourseNavigationRolesId, Header, headerApp, HelpButton, helpButtonSlotOperation, helpWidgetId } from './header'; | ||
| export { providesCourseNavigationRolesId, Header, headerApp, HelpButton, helpButtonSlotOperation, helpWidgetId, providesMasqueradeBarRolesId } from './header'; |
There was a problem hiding this comment.
Let's clean this up. Sort and make it multi-line.
| const { formatMessage } = useIntl(); | ||
|
|
||
| return (!didMount ? null : ( | ||
| <QueryClientProvider client={queryClient}> |
|
|
||
| return (!didMount ? null : ( | ||
| <QueryClientProvider client={queryClient}> | ||
| <div data-testid="instructor-toolbar"> |
There was a problem hiding this comment.
Let's try to avoid using data-testid as much as possible and use more human-mimicking testing patterns.
Also, this bar is supposed to be generic, right? Not just for the Instructor Dashboard.
| import { appId } from '../constants'; | ||
|
|
||
| function getStudioUrl(courseId?: string, unitId?: string): string | undefined { | ||
| const urlBase = getAppConfig(appId).STUDIO_BASE_URL; |
There was a problem hiding this comment.
Don't we have a siteConfig.studioBaseUrl, yet? If not it should be added as part of this.
| <Form.Control | ||
| aria-labelledby="masquerade-search-label" | ||
| label={intl.formatMessage(messages.userNameLabel)} | ||
| onKeyPress={handleKeyPress} |
There was a problem hiding this comment.
Claude tells me that onKeyPress has been deprecated. We should be using onKeyDown instead.
| await waitFor(() => expect(mockGetMasqueradeOptions).toHaveBeenCalled()); | ||
| // Verify the widget rendered (the error propagation from MasqueradeWidget | ||
| // to MasqueradeBar's Alert is an integration concern tested separately) | ||
| expect(screen.getByTestId('instructor-toolbar')).toBeInTheDocument(); |
There was a problem hiding this comment.
As mentioned elsewhere, we have to avoid using getByTestId as much as possible.
| }, []); | ||
|
|
||
| const urlStudio = getStudioUrl(courseId, unitId); | ||
| const [masqueradeErrorMessage, showMasqueradeError] = useState<string | null>(null); |
There was a problem hiding this comment.
| const [masqueradeErrorMessage, showMasqueradeError] = useState<string | null>(null); | |
| const [masqueradeErrorMessage, setMasqueradeErrorMessage] = useState<string | null>(null); |
| const handleSubmit = React.useCallback(async (payload: Payload) => { | ||
| onError(''); // Clear any error | ||
| return mutation.mutateAsync(payload); | ||
| }, [onError, mutation]); |
There was a problem hiding this comment.
useMutation returns a fresh object on every render, so depending on mutation in useCallback defeats the memoization. Depending on mutation.mutateAsync is the usual fix.
| }; | ||
| onSubmit(payload).then((data) => { | ||
| if (data && data.success) { | ||
| global.location.reload(); |
There was a problem hiding this comment.
Do we really have to reload the entire page? There must be a more elegant solution.
| payload.user_partition_id = userPartitionId!; | ||
| } | ||
| onSubmit(payload).then(() => { | ||
| global.location.reload(); |
There was a problem hiding this comment.
As noted elsewhere, there must be a better option that reloading the whole page.
Masquerade bar
As part of this issue on the instructor dashboard the masquerade bar needed to be added to frontend base.
description
The masquerade Bar was took it from learning project, and updated to use react query and context inside the frontend base.
screenshots
this is an example running on instructor dashboard


Example of role Staff
Example of role Student